Skip to content

Mark tasks carrying an artifact or a link on the dashboard - #410

Draft
tildesrc wants to merge 4 commits into
mainfrom
panopticon/dashboard-mark-column
Draft

tildesrc wants to merge 4 commits into
mainfrom
panopticon/dashboard-mark-column

Conversation

@tildesrc

@tildesrc tildesrc commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Scanning the task table told you a task's state and whose turn it was, but not whether it had a plan to read or a PR to open — you had to press a or p on each row to find out.

This adds a two-slot marks column, left of the name it annotates: when the task has an unhidden artifact, when it has a url. The header doubles as the legend.

  state          turn       container   repo       ❏ ➚  slug[memo]
  ITERATING      agent      live        web-api    ❏ ➚  add-oauth[Add OAuth login]
  PLANNING       user       live        web-api    ❏    fix-upload[Flaky S3 upload]
  MERGING        agent      starting    dashboard    ➚  dark-mode[Dark-mode theme]
  ORCHESTRATING  agent      live        infra           q3-cleanup[Q3 tech-debt]

Getting the bit to the dashboard

Artifacts are files rather than task columns, so presence has to ride along on the list response or the dashboard would need a request per row on every refresh. GET /tasks now reports has_artifacts, resolved through a new ArtifactStore.has_unhidden_artifacts(task_id). It's concrete on the ABC — defaulting to a scan of list(), the way link_slug defaults to a no-op — so no adapter breaks by not knowing about it, and the filesystem store overrides it with an os.scandir that stops at the first unhidden entry.

Writing an artifact now bumps the change feed too. An artifact stores no row of its own, so without that a newly written plan.md wouldn't wake a parked long-poll and the mark would lag until some unrelated mutation came along.

The dotfile rule that decides "unhidden" moves to core.artifacts.is_hidden, shared with the dashboard's existing "Show hidden" toggle rather than spelled out at each surface.

Why these glyphs

(U+274F) and (U+279A) rather than 📁/🔗: both are East_Asian_Width=Neutral with no emoji presentation form, so they occupy exactly one cell in every terminal. Emoji are Width=Wide, which makes the rendered column width depend on the viewer's terminal and font. A test pins equal cell width across all four mark combinations, so a later edit can't quietly regress it. The two marks are separated by a space, making the cell three cells wide.

Notes for the reviewer

  • has_artifacts is on the summary shape only, not TaskOut. _task_out is sync and called from ~20 mutation handlers; making it async to add a listdir per mutation response isn't worth it for a field only the table needs, and GET /tasks/{id}/artifacts already answers it exactly for one task.
  • Tests that read row cells positionally now resolve the index by column label, so inserting a column doesn't renumber assertions across the file.
  • Pre-existing and left alone: the table already renders Ambiguous-width glyphs (the │ ├─ └─ connectors, the status placeholder, the snoozed ·), which do drift in a CJK-configured terminal. Independent of this change and worth its own decision.

Plan: the task's plan.md artifact.

panopticon agent and others added 3 commits September 13, 2026 18:20
Scanning the task table told you a task's state and whose turn it was, but not
whether it had a plan to read or a PR to open — you had to press `a` or `p` on
each row to find out. Add a two-slot marks column, left of the name it
annotates: `❏` when the task has an unhidden artifact, `➚` when it has a url.
The header doubles as the legend.

Artifacts are files rather than task columns, so presence has to ride along on
the list response or the dashboard would need a request per row on every
refresh. `GET /tasks` now reports `has_artifacts`, resolved once per response
through a new bulk `ArtifactStore.tasks_with_artifacts`. It is concrete on the
ABC (defaulting to `list()` per id, like `link_slug` defaults to a no-op), with
the filesystem store overriding it with a single-pass `os.scandir` that stops
at the first unhidden entry. Writing an artifact now bumps the change feed too
— it stores no row of its own, so without that a newly written plan would not
wake a parked long-poll and the mark would lag until an unrelated mutation.

The dotfile rule that decides "unhidden" moves to `core.artifacts.is_hidden`,
shared with the dashboard's existing "Show hidden" toggle.

`❏` (U+274F) and `➚` (U+279A) rather than 📁/🔗 because both are
East_Asian_Width=Neutral with no emoji presentation form: exactly one cell in
every terminal. Emoji are Width=Wide, which would make the column's rendered
width depend on the viewer's terminal and font. A test pins equal cell width
across all four mark combinations to keep a later edit from regressing that.

Tests that read row cells positionally now resolve the index by column label,
so inserting a column doesn't renumber assertions across the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`❏➚` read as one glyph cluster at a glance. A space between the slots makes
each mark legible on its own; the cell is now three cells wide (mark, gap,
mark) and still constant whichever marks a row carries.

The header and the cell format share one `_MARKS_LABEL` definition, so the
legend can't drift from what the rows render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`tasks_with_artifacts(ids) -> set` was the only plural verb on a store whose
every other method (`get`, `list`, `put`, `link_slug`) takes one task id, and
it coupled the caller to passing ids it had just read out of the store.
Replace it with `has_unhidden_artifacts(task_id) -> bool`, so the interface is
uniform and a future single-task caller — `has_artifacts` on the task detail,
say — reads naturally instead of going through a one-element list.

The cost is a thread hop per row rather than one per response: ~19ms against
~3ms for 50 tasks, all of it hop overhead rather than filesystem work. That is
well inside the noise for a TUI refresh at dashboard scale, and the uniform
interface is worth it. Revisit if a fleet ever grows large enough to feel it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lambda: sorted(p.name for p in task_dir.iterdir() if p.is_file())
)

def _has_artifacts_sync(self, task_id: str) -> bool:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this synchronous?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because it's blocking filesystem I/O, and none of that may run on the event loop — os.scandir (like the iterdir/read_bytes/is_dir calls in the methods above) blocks the calling thread. So the sync function is the body that has_unhidden_artifacts hands to a worker thread:

return await asyncio.to_thread(self._has_artifacts_sync, task_id)

The reason it's a separate named method, rather than the inline lambda the sibling methods pass to to_thread, is that this body needs with and try/except — neither fits in a lambda. _link_slug_sync/link_slug is split for the same reason.

Pushed fe6f338 to say that in the docstring, since the question was fair: it explained the scandir choice but never the sync/async split.

The docstring explained the scandir choice but not the sync/async split, so
the obvious review question — why isn't this just async? — had no answer in
the code. Record it: it's blocking filesystem I/O handed to a worker thread by
`has_unhidden_artifacts`, and it's a named method rather than the inline
lambda the sibling methods use because it needs `with` and `try`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant